/*
 * Copyright 2020 VicTools.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.github.victools.jsonschema.plugin.maven;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.victools.jsonschema.generator.Module;
import com.github.victools.jsonschema.generator.OptionPreset;
import com.github.victools.jsonschema.generator.SchemaGenerator;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.generator.impl.Util;
import com.github.victools.jsonschema.module.jackson.JacksonModule;
import com.github.victools.jsonschema.module.jackson.JacksonOption;
import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationModule;
import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationOption;
import com.github.victools.jsonschema.module.javax.validation.JavaxValidationModule;
import com.github.victools.jsonschema.module.javax.validation.JavaxValidationOption;
import com.github.victools.jsonschema.module.swagger15.SwaggerModule;
import com.github.victools.jsonschema.module.swagger15.SwaggerOption;
import com.github.victools.jsonschema.module.swagger2.Swagger2Module;
import io.github.classgraph.ClassGraph;
import io.github.classgraph.ClassInfo;
import io.github.classgraph.ClassInfoList;
import io.github.classgraph.ScanResult;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.lang.reflect.Array;
import java.lang.reflect.InvocationTargetException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.plugins.annotations.ResolutionScope;
import org.apache.maven.project.MavenProject;

/**
 * Maven plugin for the victools/jsonschema-generator.
 */
@Mojo(name = "generate",
        defaultPhase = LifecyclePhase.COMPILE,
        requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME,
        requiresDependencyCollection = ResolutionScope.COMPILE_PLUS_RUNTIME,
        threadSafe = true)
public class SchemaGeneratorMojo extends AbstractMojo {

    /**
     * Full name or glob pattern of the classes for which the JSON schema will be generated.
     */
    @Parameter(property = "classNames")
    private String[] classNames;

    /**
     * Full name or glob pattern of the packages for which a JSON schema will be generated for each contained class.
     */
    @Parameter(property = "packageNames")
    private String[] packageNames;

    /**
     * Full name or glob pattern of the classes NOT to generate a JSON schema for.
     */
    @Parameter(property = "excludeClassNames")
    private String[] excludeClassNames;

    /**
     * Full name of annotations for whose annotated classes the JSON schema will be generated.
     */
    @Parameter(property = "annotations")
    private List<AnnotationParameter> annotations = new ArrayList<>();

    /**
     * The classpath to look for classes to generate schema files.
     */
    @Parameter(property = "classpath", defaultValue = "WITH_RUNTIME_DEPENDENCIES")
    private ClasspathType classpath;

    /**
     * The directory path where the schema files are generated.
     * <br>
     * By default, this is: {@code src/main/resources}
     */
    @Parameter(property = "schemaFilePath")
    private File schemaFilePath;

    /**
     * The name of the file in which the generated schema is written. Allowing for two placeholders:
     * <ul>
     * <li><code>{0}</code> - containing the simple class name of the class for which the schema was generated</li>
     * <li><code>{1}</code> - containing the package path of the class for which the schema was generated</li>
     * </ul>
     * The default name is: <code>{0}-schema.json</code>
     */
    @Parameter(property = "schemaFileName", defaultValue = "{0}-schema.json")
    private String schemaFileName;

    /**
     * The schema version to be used: DRAFT_6, DRAFT_7, DRAFT_2019_09 or DRAFT_2020_12.
     */
    @Parameter(property = "schemaVersion", defaultValue = "DRAFT_7")
    private SchemaVersion schemaVersion;

    /**
     * The options for the generator.
     */
    @Parameter
    private GeneratorOptions options;

    /**
     * Selection of Modules that need to be activated during generation.
     */
    @Parameter
    private GeneratorModule[] modules;

    /**
     * Variable to control whether the build shall abort if no classes matching the pattern are found.
     */
    @Parameter(property = "failIfNoClassesMatch", defaultValue = "true")
    private boolean failIfNoClassesMatch;

    /**
     * The Maven project.
     */
    @Parameter(defaultValue = "${project}", required = true, readonly = true)
    MavenProject project;

    /**
     * The generator to be used for all schema generations.
     */
    private SchemaGenerator generator;

    /**
     * The classloader used for loading generator modules and classes.
     */
    private URLClassLoader classLoader;

    /**
     * The list of all the classes on the classpath.
     */
    private List<PotentialSchemaClass> allTypes;

    /**
     * Invoke the schema generator.
     *
     * @throws MojoExecutionException An exception in case of errors and unexpected behavior
     */
    @Override
    public synchronized void execute() throws MojoExecutionException {
        // trigger initialization of the generator instance
        this.getGenerator();

        for (String className : Util.nullSafe(this.classNames)) {
            this.getLog().info("Generating JSON Schema for <className>" + className + "</className>");
            this.generateSchema(className, false);
        }
        for (String packageName : Util.nullSafe(this.packageNames)) {
            this.getLog().info("Generating JSON Schema for <packageName>" + packageName + "</packageName>");
            this.generateSchema(packageName, true);
        }
        if (Util.isNullOrEmpty(this.classNames) && Util.isNullOrEmpty(this.packageNames) && !Util.isNullOrEmpty(this.annotations)) {
            this.getLog().info("Generating JSON Schema for all annotated classes");
            this.generateSchema("**/*", false);
        }
    }

    /**
     * Generate the JSON schema for the given className.
     *
     * @param classOrPackageName The name or glob pattern of the class or package
     * @param targetPackage whether the given name or glob pattern refers to a package
     * @throws MojoExecutionException In case of problems
     */
    private void generateSchema(String classOrPackageName, boolean targetPackage) throws MojoExecutionException {
        Predicate<String> filter = GlobHandler.createClassOrPackageNameFilter(classOrPackageName, targetPackage);
        List<PotentialSchemaClass> matchingClasses = this.getAllClassNames().stream()
                .filter(entry -> filter.test(entry.getAbsolutePathToMatch()))
                .sorted()
                .collect(Collectors.toList());
        for (PotentialSchemaClass potentialTarget : matchingClasses) {
            if (potentialTarget.isAlreadyGenerated()) {
                this.getLog().info("- Skipping already generated " + potentialTarget.getFullClassName());
            } else {
                // Load the class for which the schema will be generated
                Class<?> schemaClass = this.loadClass(potentialTarget.getFullClassName());
                this.generateSchema(schemaClass);
                potentialTarget.setAlreadyGenerated();
            }
        }
        if (matchingClasses.isEmpty()) {
            this.logForNoClassesMatchingFilter(classOrPackageName);
        }
    }

    /**
     * Generate the JSON schema for the given className.
     *
     * @param schemaClass The class for which the schema is to be generated
     * @throws MojoExecutionException In case of problems
     */
    private void generateSchema(Class<?> schemaClass) throws MojoExecutionException {
        JsonNode jsonSchema = getGenerator().generateSchema(schemaClass);
        File file = getSchemaFile(schemaClass);
        this.getLog().info("- Writing schema to file: " + file);
        this.writeToFile(jsonSchema, file);
    }

    private void logForNoClassesMatchingFilter(String classOrPackageName) throws MojoExecutionException {
        StringBuilder message = new StringBuilder("No matching class found for \"")
                .append(classOrPackageName)
                .append("\" on classpath");
        if (!Util.isNullOrEmpty(this.excludeClassNames)) {
            message.append(" that wasn't excluded");
        }
        if (this.failIfNoClassesMatch) {
            message.append(".\nYou can change this error to a warning by setting: <failIfNoClassesMatch>false</failIfNoClassesMatch>");
            throw new MojoExecutionException(message.toString());
        }
        this.getLog().warn(message.toString());
    }

    /**
     * Get all the names of classes on the classpath.
     *
     * @return A set of classes as found on the classpath, that are not explicitly excluded
     */
    private List<PotentialSchemaClass> getAllClassNames() {
        if (this.allTypes != null) {
            return this.allTypes;
        }
        ClassGraph classGraph = new ClassGraph()
                .overrideClasspath(classpath.getClasspathElements(this.project))
                .enableClassInfo();
        boolean considerAnnotations = this.annotations != null && !this.annotations.isEmpty();
        if (considerAnnotations) {
            classGraph.enableAnnotationInfo();
        }
        ClassInfoList.ClassInfoFilter filter = createClassInfoFilter(considerAnnotations);
        try (ScanResult scanResult = classGraph.scan()) {
            Stream<ClassInfo> allTypesStream;
            if (considerAnnotations) {
                allTypesStream = this.annotations.stream()
                        .flatMap(a -> scanResult.getClassesWithAnnotation(a.className).filter(filter).stream())
                        .distinct();
            } else {
                allTypesStream = scanResult.getAllClasses().filter(filter).stream();
            }
            this.allTypes = allTypesStream
                    .map(PotentialSchemaClass::new)
                    .collect(Collectors.toList());
        }
        return this.allTypes;
    }

    /**
     * Based on the plugin configuration, create a filter instance that determines whether a given classpath element should be considered.
     *
     * @param considerAnnotations whether the plugin configuration includes looking up types by certain annotations
     * @return filter instance to apply on a ClassInfoList containing possibly eligible classpath elements
     */
    private ClassInfoList.ClassInfoFilter createClassInfoFilter(boolean considerAnnotations) {
        Set<Predicate<String>> exclusions = Util.nullSafe(this.excludeClassNames).stream()
                .map(excludeEntry -> GlobHandler.createClassOrPackageNameFilter(excludeEntry, false))
                .collect(Collectors.toSet());
        Set<Predicate<String>> inclusions;
        if (considerAnnotations) {
            inclusions = Collections.singleton(input -> true);
        } else {
            inclusions = new HashSet<>();
            Util.nullSafe(this.classNames).stream()
                    .map(className -> GlobHandler.createClassOrPackageNameFilter(className, false))
                    .forEach(inclusions::add);
            Util.nullSafe(this.packageNames).stream()
                    .map(packageName -> GlobHandler.createClassOrPackageNameFilter(packageName, true))
                    .forEach(inclusions::add);
        }
        return element -> {
            String classPathEntry = element.getName().replaceAll("\\.", "/");
            if (exclusions.stream().anyMatch(exclude -> exclude.test(classPathEntry))) {
                this.getLog().debug("  Excluding: " + element.getName());
                return false;
            }
            if (inclusions.stream().anyMatch(include -> include.test(classPathEntry))) {
                this.getLog().debug("  Including: " + element.getName());
                return true;
            }
            return false;
        };
    }

    /**
     * Return the file in which the schema has to be written.
     *
     * <p>
     * The path is determined based on the {@link #schemaFilePath} parameter.
     * <br>
     * The name of the file is determined based on the {@link #schemaFileName} parameter, which allows for two placeholders:
     * <ul>
     * <li><code>{0}</code> - containing the simple name of the class the schema was generated for</li>
     * <li><code>{1}</code> - containing the package path of the class the schema was generated for</li>
     * </ul>
     * </p>
     * The default path is: {@code src/main/resources}
     * <br>
     * The default name is: <code>{0}-schema.json</code>
     *
     * @param mainType targeted class for which the schema is being generated
     * @return The full path name of the schema file
     */
    private File getSchemaFile(Class<?> mainType) {
        // At first find the root location where the schema files are written
        File directory;
        if (this.schemaFilePath == null) {
            directory = new File("src" + File.separator + "main" + File.separator + "resources");
            this.getLog().debug("- No 'schemaFilePath' configured. Applying default: " + directory);
        } else {
            directory = this.schemaFilePath;
        }

        // Then build the full qualified file name.
        String fileName = MessageFormat.format(this.schemaFileName,
                // placeholder {0}
                mainType.getSimpleName(),
                // placeholder {1}
                mainType.getPackage().getName().replace('.', File.separatorChar));
        File schemaFile = new File(directory, fileName);

        // Make sure the directory is available
        try {
            Files.createDirectories(schemaFile.getParentFile().toPath());
        } catch (IOException e) {
            this.getLog().warn("Failed to ensure existence of " + schemaFile.getParent(), e);
        }

        return schemaFile;
    }

    /**
     * Get the JSON Schema generator. Create it when required.
     * <br>
     * Configuring it the specified options and adding the required modules.
     *
     * @return The configured generator
     * @throws MojoExecutionException Error exception
     */
    private SchemaGenerator getGenerator() throws MojoExecutionException {
        if (this.generator == null) {
            this.getLog().debug("Initializing Schema Generator");

            // Start with the generator builder
            SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(this.schemaVersion, this.getOptionPreset());

            // Add options when required
            this.setOptions(configBuilder);

            // Register the modules when specified
            this.setModules(configBuilder);

            // And construct the generator
            SchemaGeneratorConfig config = configBuilder.build();
            this.generator = new SchemaGenerator(config);
        }

        return this.generator;
    }

    /**
     * Determine the standard option preset of the generator. Take it from the configuration or set the default. The default is: PLAIN_JSON
     *
     * @return The OptionPreset
     */
    private OptionPreset getOptionPreset() {
        if (this.options != null && this.options.preset != null) {
            return this.options.preset.getPreset();
        }
        this.getLog().debug("- No 'options/preset' configured. Applying default: PLAIN_JSON");
        return OptionPreset.PLAIN_JSON;
    }

    /**
     * Set the generator options form the configuration.
     *
     * @param configBuilder The configbuilder on which the options are set
     */
    private void setOptions(SchemaGeneratorConfigBuilder configBuilder) {
        if (this.options != null) {
            // Enable all the configured options
            Util.nullSafe(this.options.enabled).forEach(configBuilder::with);
            // Disable all the configured options
            Util.nullSafe(this.options.disabled).forEach(configBuilder::without);
        }
    }

    /**
     * Configure all the modules on the generator.
     *
     * @param configBuilder The builder on which the modules are added.
     * @throws MojoExecutionException Invalid module name or className configured
     */
    @SuppressWarnings("unchecked")
    private void setModules(SchemaGeneratorConfigBuilder configBuilder) throws MojoExecutionException {
        for (GeneratorModule module : Util.nullSafe(this.modules)) {
            if (!Util.isNullOrEmpty(module.className)) {
                this.addCustomModule(module.className, configBuilder);
            } else if (!Util.isNullOrEmpty(module.name)) {
                this.addStandardModule(module, configBuilder);
            }
        }
    }

    /**
     * Instantiate and apply the custom module with the given class name to the config builder.
     *
     * @param moduleClassName Class name of the custom module to add.
     * @param configBuilder The builder on which the module is added.
     * @throws MojoExecutionException When failing to instantiate the indicated module class.
     */
    private void addCustomModule(String moduleClassName, SchemaGeneratorConfigBuilder configBuilder) throws MojoExecutionException {
        this.getLog().debug("- Adding custom Module " + moduleClassName);
        try {
            Class<? extends Module> moduleClass = (Class<? extends Module>) this.loadClass(moduleClassName);
            Module moduleInstance = moduleClass.getConstructor().newInstance();
            configBuilder.with(moduleInstance);
        } catch (ClassCastException | InstantiationException
                 | IllegalAccessException | NoSuchMethodException
                 | InvocationTargetException e) {
            throw new MojoExecutionException("Error: Can not instantiate custom module " + moduleClassName, e);
        }
    }

    /**
     * Instantiate and apply the standard module with the given name to the config builder.
     *
     * @param module Record in the modules section from the pom containing at least a name.
     * @param configBuilder The builder on which the module is added.
     * @throws MojoExecutionException When an invalid module name or option is specified.
     */
    private void addStandardModule(GeneratorModule module, SchemaGeneratorConfigBuilder configBuilder) throws MojoExecutionException {
        switch (module.name) {
        case "Jackson":
            this.getLog().debug("- Adding Jackson Module");
            this.addStandardModuleWithOptions(module, configBuilder, JacksonModule::new, JacksonOption.class);
            break;
        case "JakartaValidation":
            this.getLog().debug("- Adding Jakarta Validation Module");
            this.addStandardModuleWithOptions(module, configBuilder, JakartaValidationModule::new, JakartaValidationOption.class);
            break;
        case "JavaxValidation":
            this.getLog().debug("- Adding Javax Validation Module");
            this.addStandardModuleWithOptions(module, configBuilder, JavaxValidationModule::new, JavaxValidationOption.class);
            break;
        case "Swagger15":
            this.getLog().debug("- Adding Swagger 1.5 Module");
            this.addStandardModuleWithOptions(module, configBuilder, SwaggerModule::new, SwaggerOption.class);
            break;
        case "Swagger2":
            this.getLog().debug("- Adding Swagger 2.x Module");
            configBuilder.with(new Swagger2Module());
            break;
        default:
            throw new MojoExecutionException("Error: Module does not have a name in "
                    + "['Jackson', 'JakartaValidation', 'JavaxValidation', 'Swagger15', 'Swagger2'] or does not have a custom classname.");
        }
    }

    /**
     * Add a standard module to the generator configuration.
     *
     * @param <T> type of option enum the standard module expects in its constructor
     * @param configBuilder builder on which the standard module should be added
     * @param module record in the modules section from the pom
     * @param moduleConstructor module constructor expecting an array of options
     * @param optionType enum type for the module options (e.g., JacksonOption or JakartaValidationOption)
     * @throws MojoExecutionException in case of problems
     */
    private <T extends Enum<T>> void addStandardModuleWithOptions(GeneratorModule module, SchemaGeneratorConfigBuilder configBuilder,
            Function<T[], Module> moduleConstructor, Class<T> optionType) throws MojoExecutionException {
        Stream.Builder<T> optionStream = Stream.builder();
        for (String optionName : Util.nullSafe(module.options)) {
            try {
                optionStream.add(Enum.valueOf(optionType, optionName));
            } catch (IllegalArgumentException e) {
                throw new MojoExecutionException("Error: Unknown " + module.name + " option " + optionName, e);
            }
        }
        T[] options = optionStream.build().toArray(count -> (T[]) Array.newInstance(optionType, count));
        configBuilder.with(moduleConstructor.apply(options));
    }

    /**
     * Construct the classloader based on the project classpath.
     *
     * @return The classloader
     */
    private URLClassLoader getClassLoader() {
        if (this.classLoader == null) {
            // fix the classpath such that the classloader can get classes from any possible dependency
            // this does not affect filtering, as the classgraph library uses its own classloader and allows for caching
            List<URL> urls = ClasspathType.WITH_ALL_DEPENDENCIES_AND_TESTS.getUrls(this.project);
            this.classLoader = new URLClassLoader(urls.toArray(new URL[0]),
                Thread.currentThread().getContextClassLoader());
        }
        return this.classLoader;
    }

    /**
     * Load a class from the plugin classpath enriched with the project dependencies.
     *
     * @param className Name of the class to be loaded
     * @return The loaded class
     * @throws MojoExecutionException In case of unexpected behavior
     */
    private Class<?> loadClass(String className) throws MojoExecutionException {
        try {
            return this.getClassLoader().loadClass(className);
        } catch (ClassNotFoundException e) {
            throw new MojoExecutionException("Error loading class " + className, e);
        }
    }

    /**
     * Write generated schema to a file.
     *
     * @param jsonSchema Generated schema to be written
     * @param file The file to write to
     * @throws MojoExecutionException In case of problems when writing the targeted file
     */
    private void writeToFile(JsonNode jsonSchema, File file) throws MojoExecutionException {
        ObjectMapper mapper = getGenerator().getConfig().getObjectMapper();
        try (FileOutputStream outputStream = new FileOutputStream(file);
                PrintWriter writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
            writer.print(mapper.writeValueAsString(jsonSchema));
        } catch (IOException e) {
            throw new MojoExecutionException("Error: Can not write to file " + file, e);
        }
    }
}
